返回笔记

计算机系统 - 程序执行

目录
目录

本文说明:32 位 vs 64 位

本笔记的汇编示例以 64 位(x86-64) 为主,但课堂演示通常基于 32 位(IA-32)。两者逻辑完全相同,只是寄存器名和地址宽度不同:

32 位(课堂)64 位(本文)
通用寄存器%eax %ebx %ecx %edx%rax %rbx %rcx %rdx
参数/索引寄存器%esi %edi%rsi %rdi
栈指针 / 帧指针%esp %ebp%rsp %rbp
程序计数器%eip%rip
指针/地址宽度4 字节8 字节
栈操作pushl / poplpushq / popq
函数传参方式全部压栈(无寄存器传参)前 6 个用寄存器,超出才压栈
switch 跳转表 scale,4(每项 4 字节),8(每项 8 字节)

认准寄存器前缀:e 开头 → 32 位;r 开头 → 64 位。逻辑结构一样,换个前缀即可互译。

一、快速回顾:PC 与条件码

PC(程序计数器) 就是 CPU 的 "书签"——始终指向 下一条要执行的指令地址。在 x86 叫 %eip(32位)/ %rip(64位)。CPU 的执行循环本质上就是:取指 → PC 自增 → 执行 → 重复。跳转/调用/返回指令会直接改写 PC 来打破顺序执行。

条件码 是 EFLAGS 寄存器里的标志位(CF/ZF/SF/OF),算术指令执行后自动设置。cmptest 是"只设标志不存结果"的专用指令。程序通过 je/jl/jg 等条件跳转来读取标志位,实现 if/else 和循环控制流。

四个核心条件码

标志全称含义何时置 1
CFCarry Flag(进位标志)无符号运算溢出最高位产生进位/借位,常用于无符号数大小比较和多精度算术
ZFZero Flag(零标志)结果为零运算结果 = 0,cmp a, b 且 a == b 时置 1
SFSign Flag(符号标志)结果为负结果的最高位(符号位)= 1,即有符号数为负
OFOverflow Flag(溢出标志)有符号运算溢出正 + 正 = 负 或 负 + 负 = 正,用于有符号数溢出检测

条件码 → 条件跳转对照

JX 指令

跳转指令含义依赖的标志
je / jz等于 / 结果为零ZF = 1
jne / jnz不等于ZF = 0
jl / jnge有符号小于SF ≠ OF
jle / jng有符号小于等于ZF = 1 或 SF ≠ OF
jg / jnle有符号大于ZF = 0 且 SF = OF
jge / jnl有符号大于等于SF = OF
jb / jnae无符号小于(Below)CF = 1
ja / jnbe无符号大于(Above)CF = 0 且 ZF = 0
js负数SF = 1

记忆技巧:有符号比较看 SF/OF(j 系列:jl/jg/jle/jge);无符号比较看 CF(j 系列用 b/a 后缀:Below/Above)。cmp a, b 实质是计算 b - a 并设标志,test a, b 实质是计算 a & b 并设标志(常见 testl %edi, %edi 用来检测某寄存器是否为零)。

深入:cmp A, B 做了什么,CPU 怎么判断大小

cmp A, B 在硬件上执行 B − A,结果只用来设标志,不写回寄存器。

无符号比较:看 CF(进位/借位)

减法在硬件上是加补码:B − A = B + (~A + 1)。如果最高位发生借位(相当于加法产生进位溢出),CPU 置 CF = 1,意味着"减法不够减",即 B < A(无符号)。

B − A 结果CFZF含义(无符号)
正数(够减)00B > A
恰好为 001B = A
负数(需借位)10B < A

有符号比较:为什么不能只看 SF,还要配合 OF

正常情况下,SF 反映结果的符号位:SF = 1 说明 B − A < 0,即 B < A。

但当结果超出有符号数的表示范围时(发生溢出,OF = 1),符号位被"截断"成了错误的值,这时 SF 反映的是截断后的假符号,必须用 SF XOR OF 才能还原真实大小关系。

SFOFSF XOR OF有符号含义
000B − A ≥ 0,无溢出,B ≥ A
101B − A < 0,无溢出,B < A
011溢出!符号位被翻转成 0,实际 B − A < 0,B < A
110溢出!符号位被翻转成 1,实际 B − A > 0,B > A

因此 jl(有符号小于 <)的判断条件就是 SF ≠ OFjge (有符号大于 >)的条件是 SF = OF
OF 参与的比较一般都是有符号数的比较。

两个溢出的具体例子(用 8 位演示)

例 1:SF=1, OF=1 → 实际 B > A

B = 127(0111 1111),A = −2(1111 1110),计算 B − A = 127 − (−2) = 129,超出 8 位有符号上限 127。

  0111 1111   (127)
+ 0000 0010   (~(-2)+1 = 2 的补码)
-----------
  1000 0001   = -127 (8位截断后)

最高位进位舍去(CF=1),存储结果 1000 0001 = −127,SF = 1(显示"负数");但真实结果是正数 +129,所以 OF = 1。SF = OF = 1 → jg 条件成立,正确判断 B > A。

例 2:SF=0, OF=1 → 实际 B < A

B = −128(1000 0000),A = 1(0000 0001),计算 B − A = −128 − 1 = −129,超出 8 位有符号下限 −128。

  1000 0000   (-128)
+ 1111 1111   (~1+1 = -1 的补码)
-----------
  0111 1111   = +127 (8位截断后,最高位进位舍去)

最高位进位舍去(CF=1),存储结果 0111 1111 = +127,SF = 0(显示"正数");但真实结果是负数 −129,所以 OF = 1。SF ≠ OF → jl 条件成立,正确判断 B < A。

结论:CPU 的设计精妙之处在于,无论有没有溢出,只要检查 SF XOR OF,就能得到有符号比较的正确结论。硬件不需要知道"发没发生溢出",只需读两个标志位做 XOR 即可。

setX 指令:把条件码"物化"成一个字节

jX 是根据条件码跳转setX 则是根据同一套条件码写入 0 或 1 到一个单字节寄存器。两者依赖的条件完全相同,只是动作不同。

sete   %al       # ZF=1 → al=1,否则 al=0
setl   %al       # SF≠OF → al=1,否则 al=0
setg   %al       # ZF=0 且 SF=OF → al=1,否则 al=0

常用 setX 速查:

指令含义条件
sete / setz等于ZF = 1
setne / setnz不等于ZF = 0
setl / setnge有符号小于SF ≠ OF
setle / setng有符号小于等于ZF = 1 或 SF ≠ OF
setg / setnle有符号大于ZF = 0 且 SF = OF
setge / setnl有符号大于等于SF = OF
setb / setnae无符号小于CF = 1
seta / setnbe无符号大于CF = 0 且 ZF = 0
sets负数SF = 1

setX 的典型用途:布尔表达式赋值

C 语言里的比较表达式(a > bx == y)会产生 0 或 1,编译器用 setX 来实现:

int is_greater(int a, int b) {
    return a > b;
}
is_greater:
    cmpl   %esi, %edi      # a - b,设标志(注意:edi=a, esi=b,计算 a-b)
    setg   %al             # al = (a > b) ? 1 : 0
    movzbl %al, %eax       # 零扩展到 32 位(清掉 eax 高位)
    ret

%eax 是 32 位寄存器,setX 只往最低的 1 个字节(%al)写 0 或 1,剩下 3 个字节原封不动。movzbl %al, %eax 就是把高 3 字节清零,防止之前残留的值混进来污染返回结果。

更多细节参见 基础认知 中的寄存器和条件跳转章节。


二、C → 汇编:编译器到底把代码变成了什么

这一章是核心。我们不光要知道"C 能编译成汇编",更要掌握 各种 C 构造编译后长什么样

2.1 变量赋值与算术

最简单的起点:

int a = 3;
int b = a + 5;
movl   $3, -4(%rbp)          # a = 3(写入栈 rbp-4)
movl   -4(%rbp), %eax        # eax = a(从栈读到寄存器)
addl   $5, %eax              # eax = eax + 5 = 8
movl   %eax, -8(%rbp)        # b = 8(写回栈 rbp-8)

观察几件事

2.2 函数调用的完整画面

int add(int x, int y) {
    return x + y;
}

int main() {
    int result = add(3, 5);
    return result;
}
add:
    pushq  %rbp                # ① 保存调用方栈帧
    movq   %rsp, %rbp          # ② 建立自己的栈帧
    movl   %edi, -4(%rbp)      # ③ 把参数 x 从寄存器存到栈上
    movl   %esi, -8(%rbp)      # ④ 把参数 y 从寄存器存到栈上
    movl   -4(%rbp), %eax      # ⑤ eax = x
    addl   -8(%rbp), %eax      # ⑥ eax += y
    popq   %rbp                # ⑦ 恢复栈帧
    ret                        # ⑧ 返回(eax 里就是返回值)

main:
    pushq  %rbp
    movq   %rsp, %rbp
    movl   $3, %edi            # 第 1 参数 → %edi
    movl   $5, %esi            # 第 2 参数 → %esi
    call   add                 # 压入返回地址,跳到 add
    movl   %eax, -4(%rbp)      # result = 返回值(从 %eax 取)
    movl   -4(%rbp), %eax      # 把 result 放回 eax 作为 main 的返回值
    popq   %rbp
    ret

寄存器约定速记

角色寄存器记忆
返回值%eax / %rax函数的"出口",所有函数返回值都在这
第 1 参数%edi / %rdi6 个参数按顺序用 di, si, dx, cx, r8, r9
第 2 参数%esi / %rsi
第 3 参数%edx / %rdx
第 4 参数%ecx / %rcx
第 5~6 参数%r8d / %r9d
≥7 个参数栈传递逆序压栈
栈顶指针%rsp永远指向栈顶
栈帧基址%rbp当前函数栈帧的 "锚点"

核心规则:返回值永远在 %eax(32位)/ %rax(64位)。 这就是为什么你看到每个函数末尾都在往 %eax 里塞东西。

2.3 swap——经典的指针操作

void swap(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}
swap:
    movl  (%rdi), %eax         # tmp = *a(rdi 是第1参数:指针 a)
    movl  (%rsi), %edx         # edx = *b(rsi 是第2参数:指针 b)
    movl  %edx, (%rdi)         # *a = *b(往 a 指向的地址写入 b 的值)
    movl  %eax, (%rsi)         # *b = tmp(往 b 指向的地址写入原来的 a)
    ret

swap 是理解指针操作汇编的绝佳例子:(%rdi) = 解引用指针 a(去 rdi 存的地址取值),(%rsi) = 解引用指针 b。括号 = 解引用,没有括号 = 地址本身。注意编译器发现 tmp 可以直接用 %eax 暂存,根本不需要分配栈空间。

2.4 if-else

int max(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}
max:
    cmpl  %esi, %edi           # a - b,设标志(不存结果)
    jle   .L_else              # a ≤ b → 跳到 else
    movl  %edi, %eax           # return a(放进 eax)
    ret
.L_else:
    movl  %esi, %eax           # return b(放进 eax)
    ret

编译器把 if (a > b) 翻转成 jle(≤ 就跳走),让 then 分支紧跟在 cmp 后面——不跳转 = 走 then,这对 CPU 分支预测更友好。

2.5 循环

while 循环

int sum(int n) {
    int s = 0;
    while (n > 0) { s += n; n--; }
    return s;
}
sum:
    movl  $0, %eax             # s = 0
    jmp   .L_test              # 先跳去检查条件
.L_body:
    addl  %edi, %eax           # s += n
    decl  %edi                 # n--
.L_test:
    testl %edi, %edi           # n 和自己按位与 → 设 ZF
    jg    .L_body              # n > 0 → 回去执行循环体
    ret                        # s 已经在 eax 里,直接返回

do-while 循环

int sum_do(int n) {
    int s = 0;
    do { s += n; n--; } while (n > 0);
    return s;
}
sum_do:
    movl  $0, %eax             # s = 0
.L_body:
    addl  %edi, %eax           # s += n   ← 直接从循环体开始,没有前置跳转
    decl  %edi                 # n--
    testl %edi, %edi           # 条件检查放在末尾
    jg    .L_body              # n > 0 → 继续循环
    ret

对比 while 的汇编,差别一目了然:

whiledo-while
入口jmp .L_test(先跳去检查条件)直接进 .L_body(无前置跳转)
条件检查位置循环体之前循环体之后
n=0 时的行为一次都不执行执行一次再检查,结果可能出错
指令数多一条前置 jmp少一条指令,略微更高效

关键:do-while 不保护 n=0 的情况

// while:n=0 时 s=0,正确
int s = 0;
while (n > 0) { s += n; n--; }

// do-while:n=0 时执行了 s += 0; n-- → n=-1,再判断 -1>0 为假退出
// 结果 s=0 碰巧对,但 n 被改成了 -1,副作用已经发生
int s = 0;
do { s += n; n--; } while (n > 0);

正因如此,编译器把 while 翻译成 "jump-to-middle"(先跳到末尾检查条件)而不是直接翻译成 do-while,就是为了保留"可能一次都不执行"的语义。

for 循环

编译器先将 for 拆成 while,然后用同样的 "jump-to-middle" 模式。

2.6 goto 与无条件跳转 jmp

C 的 goto 是唯一直接对应汇编 jmp 的构造——无条件跳到标签,不判断任何条件。

int count_down(int n) {
    int s = 0;
loop:
    if (n <= 0) goto done;
    s += n;
    n--;
    goto loop;
done:
    return s;
}
count_down:
    movl  $0, %eax             # s = 0
loop:
    testl %edi, %edi           # n & n,测试 n 是否 ≤ 0
    jle   done                 # n ≤ 0 → 跳到 done
    addl  %edi, %eax           # s += n
    decl  %edi                 # n--
    jmp   loop                 # 无条件跳回 loop
done:
    ret

为什么要了解 goto

实际写 C 代码几乎不用 goto,但它在这里有两个重要意义:

  1. 编译器的内部语言:编译器把所有控制流(if/while/for/switch)先转换成 "goto 形式"(也叫 goto code)再生成汇编。理解 goto ↔ jmp 的对应,就理解了所有控制流的汇编本质。

  2. 直接跳转 vs 间接跳转jmp 有两种形式:

    • jmp .label:直接跳转,目标地址编译期确定,对应 goto/if/while
    • jmp *%rax:间接跳转,运行时才知道跳哪,对应 switch 的跳转表和函数指针调用

switch 的跳转表就是间接跳转的典型应用

int switch_demo(int x) {
    int result;
    switch (x) {
        case 1: result = 10; break;
        case 2: result = 20; break;
        case 3: result = 30; break;
        default: result = 0;
    }
    return result;
}
switch_demo:
    cmpl   $3, %edi            # x > 3?
    ja     .default            # 超出范围 → default(无符号比较,负数也走这里)
    movslq %edi, %rdi          # x 符号扩展到 64 位(用作表下标)
    jmp    *.Ltable(,%rdi,8)   # 间接跳转:跳到 table[x] 存的地址

.Ltable:                       # 跳转表,每项 8 字节(64 位地址)
    .quad  .default            # x=0 → default
    .quad  .case1              # x=1
    .quad  .case2              # x=2
    .quad  .case3              # x=3

.case1:
    movl   $10, %eax
    ret
.case2:
    movl   $20, %eax
    ret
.case3:
    movl   $30, %eax
    ret
.default:
    movl   $0, %eax
    ret

关键点:

break 与 fall-through

fall-through 在 C 里只有一种写法(不写 break),但在汇编层面其实分两种情况,跳转表的行为完全不同。

情况一:有 break(各 case 独立)

switch (x) {
    case 1: result = 10; break;
    case 2: result = 20; break;
    case 3: result = 30; break;
}

跳转表各项指向不同地址,每个 case 末尾有 jmp .end 跳出:

.Ltable:
    .quad  .case1
    .quad  .case2
    .quad  .case3

.case1:
    movl   $10, %eax
    jmp    .end          # break → jmp .end
.case2:
    movl   $20, %eax
    jmp    .end
.case3:
    movl   $30, %eax
    jmp    .end
.end:

情况二:有代码,无 break(非空贯穿)

switch (x) {
    case 1: result = 10;   // 有代码,无 break
    case 2: result = 20;   // 有代码,无 break
    case 3: result = 30; break;
}

跳转表各项仍指向不同地址,但 case 之间没有 jmp,代码在内存中顺序相邻,执行完自然"落入"下一个 case:

.Ltable:
    .quad  .case1        # 各自不同的地址
    .quad  .case2
    .quad  .case3

.case1:
    movl   $10, %eax     # 执行完,无 jmp,顺序落入 case2
.case2:
    movl   $20, %eax     # 执行完,无 jmp,顺序落入 case3
.case3:
    movl   $30, %eax
    jmp    .end          # 只有最后才跳出
.end:

x=1 时三条 movl 全部执行,result 最终为 30。

情况三:无代码,无 break(空合并)

switch (x) {
    case 1:              // 无任何代码,无 break
    case 2:              // 无任何代码,无 break
    case 3: result = 30; break;
}

case 1、2 没有对应代码,编译器直接让跳转表里的多项指向同一个地址.case1.case2 标签根本不存在:

.Ltable:
    .quad  .case3        # x=1 → 直接指向 case3 的地址
    .quad  .case3        # x=2 → 同一个地址
    .quad  .case3        # x=3

.case3:
    movl   $30, %eax
    jmp    .end
.end:

这就是课堂上说的"跳转表内部地址一样"——不是代码顺序相邻落入,而是跳转表里直接存了相同的目标地址,x=1 和 x=3 走的是同一条路径。

情况四:带数据冲突的 fall-through(提取公共块)

当 case A 贯穿到 case B,但 case B 开头有独立的初始化赋值,物理顺序落入会把 case A 算好的值直接覆盖掉。这时编译器会提取公共后缀块,用一条内部 jmp 绕过冲突:

switch (x) {
    case 1:
        val = compute(a, b);  // case 1 算出 val
        // fall through,想复用 case 2 后半段的逻辑
    case 2:
        val = a + b;          // case 2 的初始化——会覆盖 case 1 的 val!
        result = val * 2;     // 两者共享的后续逻辑
        break;
}

直接贯穿的话,x=1 走到 val = a + b 时 case 1 的结果就被抹掉了。编译器的解法是把共享的 result = val * 2 提取成独立标签 .Lshared

.Ltable:
    .quad  .case1
    .quad  .case2

.case1:
    # val = compute(a, b)
    jmp    .Lshared       # ← 显式跳过 case2 的初始化,直奔共享块

.case2:
    # val = a + b         (case2 自己的初始化)
                          # 无 jmp,物理贯穿落入 .Lshared

.Lshared:
    # result = val * 2    (公共后缀逻辑)
    jmp    .end
.end:

执行结果:

识别特征:即使没有 break,汇编里出现了跨越部分 case 逻辑的内部 jmp(不是跳出整个 switch 的 jmp .end,而是跳进 switch 内部某个标签)。

四种情况对比

写法跳转表case 之间
有 break各项地址不同每个 case 末尾有 jmp .end
有代码无 break各项地址不同case 之间无跳转,顺序落入
无代码无 break多项地址相同无独立代码块,共用同一入口
有代码无 break 但有数据冲突各项地址不同case A 末尾有内部 jmp 跳入公共块,case B 物理落入公共块

2.7 数组的内存表示与访问

1. 一维数组(基本原则)

对于声明 T A[N];

char   A[12];    // 连续 12 字节
int    B[8];     // 连续 32 字节 (8 * 4)
double C[6];     // 连续 48 字节 (6 * 8)

2. typedef 声明的数组

typedef 并不会改变数组的底层内存表示概念,它只是给特定类型的数组起了个别名,方便代码复用或增加可读性。

typedef int zip_dig[5]; // 定义一个新类型 zip_dig,它是一个包含 5 个 int 的数组
zip_dig cmu = {1, 5, 2, 1, 3}; 

// cmu 在内存中就是连续紧靠的 5 个 int(共占 20 字节)
// cmu 的底层表现和普通的 int cmu[5] 完全相同

3. 嵌套的数组(Nested Arrays)/ 二维数组

C 语言的多维数组在内存中是 按行优先(Row-Major Order) 连续存储的。它本质上就是“数组的数组”(也就是嵌套数组),而不是像某些高级语言那样是“指针的指针”。

如果结合刚才的 typedef,嵌套数组在内存中“同构平坦”的特点会非常直观:

typedef int zip_dig[5]; // 每个 zip_dig 是连续的 5 个 int(20 字节)

zip_dig pgh[4] = {      // pgh 是一个包含 4 个 zip_dig 的嵌套数组
    {1, 5, 2, 0, 6},    // 第 0 行
    {1, 5, 2, 1, 3},    // 第 1 行
    {1, 5, 2, 1, 7},    // 第 2 行
    {1, 5, 2, 2, 7}     // 第 3 行
};
// 整个 pgh 在内存中是 4 * 20 = 80 字节的绝对连续空间

这也等价于直接声明 int pgh[4][5];

对于一般的多维数组声明 T D[R][C];

int matrix[3][4]; // 3 行 4 列的 int 数组,共占 12 * 4 = 48 字节内存

它的内存布局展开成一维:

行偏移0123
第0行[0][0][0][1][0][2][0][3]
第1行[1][0][1][1][1][2][1][3]
第2行[2][0][2][1][2][2][2][3]

4. 指针数组(Array of Pointers / 多级数组)

这就是容易和“嵌套数组(二维数组)”混淆的概念——事先声明了几个独立的数组,然后用另一个数组里的元素作为指针,分别指向它们的各自的首地址。

int row1[4] = {1, 5, 2, 6};
int row2[4] = {1, 5, 2, 3};
int row3[4] = {1, 5, 2, 7};

int *univ[3] = {row1, row2, row3}; // 这是一个包含 3 个指针的数组

🔑 核心区分:内存连续性与访存次数

  1. 连续性int pgh[3][4] 必然是一大块连续的 12*4=48 字节的内存。而 univ[3] 指向的 row1row2row3 在内存空间中可能散落各处,并不连续
  2. 访存次数:要访问二维数组 pgh[i][j],CPU 只需要通过公式算出地址,做一次寻址(Mem 读)就能拿到结果。而要访问指针数组 univ[i][j],CPU 必须做两次寻址(Mem 读):第一次先从 univ 里读出 row 的指针首地址,第二次拿这个地址加上物理偏移量再去读具体元素。

5. 汇编层面的数组与常规访问

在数组访问转化为汇编时,编译器最常使用的就是变址寻址

int get_elem(int arr[], int i) {
    return arr[i];
}
get_elem:
    movslq %esi, %rsi                 # i 符号扩展到 64 位
    movl   (%rdi, %rsi, 4), %eax      # eax = *(arr + i*4) = arr[i]
    ret

(%rdi, %rsi, 4) 三件套:首地址 + 下标 × 元素大小。C 里 arr[i] 这种看似高级的下标访问,在底层只不过是 *(arr + i) 的语法糖,生成的汇编指令完全一致。

6. 定长数组的编译器优化(Fixed-Size Array Optimization)

当数组的维度在编译期是已知常量时(定长数组),现代编译器往往能做极其强力的优化。它会直接剥离掉公式 X_D + L * (C * i + j) 里昂贵的乘法计算,将其转换为廉价的指针加法(这种优化手段叫作 强度削弱 Strength Reduction)。

来看一个经典的提取矩阵对角线元素求和的例子:

#define N 16
typedef int fix_matrix[N][N];

int diag_sum(fix_matrix A) {
    int sum = 0;
    for (int i = 0; i < N; i++) {
        sum += A[i][i]; // 访存公式本应该是:A + 4 * (16 * i + i) = A + 68 * i
    }
    return sum;
}

在开启了优化的编译器眼中(哪怕只是 -O1),它绝不会在循环里每次都傻乎乎地去算 16 * i + i。编译器会自动把它转化成等价的指针运算

// 编译器"脑补"出优化后的等价 C 代码
int diag_sum_opt(fix_matrix A) {
    int sum = 0;
    int *ptr = &A[0][0];      // 搞一个指针顺着内存爬
    int *end = &A[N][0];      // 算出结束地址当作终止条件

    do {
        sum += *ptr;
        ptr += (N + 1);       // 绝杀!对角线元素在平坦内存里相差正好 17 个位置!
    } while (ptr != end);

    return sum;
}

对应核心汇编循环(剥离了原本的乘法寻址):

.L4:
    addl  (%rax), %edx      # sum += *ptr
    addq  $68, %rax         # ptr += 17 (即 68 字节)
    cmpq  %rcx, %rax        # ptr == end?
    jne   .L4               # 否则继续循环

原本循环每次需要用 imul 来做乘法算下标,现在被彻底优化成了一条 addq $68, %rax。这就是定长数组在运算速度上的杀手锏。

2.8 结构体的内存表示与访问

理解 C 语言结构体的核心在两点:偏移量数据对齐

1. 结构体的内存布局

结构体的所有字段打包在一段连续字节中。编译器在编译期会计算并维护每个字段相对于起始地址的字节偏移量

struct rec {
    int i;       // 偏移 0 (占 4 字节)
    int j;       // 偏移 4 (占 4 字节)
    int a[2];    // 偏移 8 (占 8 字节)
    int *p;      // 偏移 16 (占 8 字节)
};               // 总大小: 24 字节

要访问 r->j,编译器会直接使用偏移量 4

2. 汇编层面的结构体访问

结构体访问在底层极其简单,本质就是 基址 + 固定偏移量

struct Point { int x; int y; };
int get_y(struct Point *p) { return p->y; }
get_y:
    movl  4(%rdi), %eax        # eax = *(p + 4) = p->y(偏移 4 固化在指令中)
    ret

汇编代码中不存在结构体的字段名和类型。编译后留下的只有硬编码的数字(偏移量)。

3. 数据对齐与内部填充

总线一次访存通常要求数据的存放地址是其自身大小的整数倍(即 KK 字节对齐)。
为了满足对齐要求,编译器会在字段之间自动插入空白的内部填充

struct S1 {
    char c;   // 1 字节
              // 👈 强行插入 3 字节内部填充,让后续的 int 满足 4 字节对齐
    int i[2]; // 8 字节
    double v; // 8 字节
};

不考虑对齐只需要 17 字节,算上内部填充后占据 20 字节。

4. 外部填充与总大小

结构体末尾往往还需要外部填充
规则:结构体的总大小,必须是它所有成员中最大对齐要求的倍数。以保证如果将该结构体作为数组元素,每个元素的起始地址都能天然对齐。

上方 S1 的最大对齐成员是 double(要求 8 字节对齐)。前面已经占据 20 字节,为了成为 8 的倍数,结尾必须加上 4 个外部填充字节,因此 S1 实际被设定为 24 字节

5. 字段重排优化

C 语言标准不允许编译器擅自改变字段的声明顺序。程序员可以通过按数据类型从大到小依次声明字段,手动消除填充空隙,大幅减小结构体体积:

// ❌ 随意排列:大小 24 字节
struct Bad {
    char c1;   // 1 字节 + 7 字节填充
    double d;  // 8 字节
    char c2;   // 1 字节 + 7 字节填充
};

// ✅ 从大到小排列:大小 16 字节
struct Good {
    double d;  // 8 字节
    char c1;   // 1 字节
    char c2;   // 1 字节 + 末尾统一补 6 字节外部填充
};

2.9 联合(Unions)的内存表示

联合(union)在语法上与 struct 极其相似,但底层内存逻辑截然相反:

总大小:仅仅取决于占据空间最大的那一个字段(同时也要满足最大对齐要求)。

union U1 {
    char c;    // 偏移 0 (占 1 字节)
    int i[2];  // 偏移 0 (占 8 字节)
    double v;  // 偏移 0 (占 8 字节)
};             // 总大小:8 字节(最大字段大小)

汇编视角:处理 u->cu->i[0]u->v 时,汇编指令去读取的内存地址完全一样(基址 + 偏移量 0),唯一的区别只在于指令读取宽度的不同(比如取 1 字节 movb 还是 8 字节 movq)。

联合的核心用途:直接解读位模式(Type Punning)

因为多字段共享同一块内存,这允许程序员绕过类型系统,直接以另一种数据类型去“强行解读”同一套二进制比特流。

// 提取 double 浮点数的底层 64 位 IEEE 754 二进制模式
unsigned long double_to_bits(double d) {
    union {
        double d;
        unsigned long u;
    } temp;
    temp.d = d;
    return temp.u; // 直接用整型的视角拿走浮点数的原始比特流
}

这种写法生成的指令极为干净,没有任何浮点转换开销。硬件层面也只是将数据压进并原封不动地拔出寄存器而已。

2.10 条件传送(CMOVcc)——无分支的 if

cmovXX src, dst 的语义:先无条件计算两个分支的值,再根据条件码决定要不要把 src 搬进 dst。整个过程没有跳转指令,CPU 流水线不需要做分支预测。

CMOVcc 指令速查

后缀命名规则与 jXX 完全相同:

指令含义条件
cmove / cmovz等于时传送ZF = 1
cmovne / cmovnz不等于时传送ZF = 0
cmovl / cmovnge有符号小于时传送SF ≠ OF
cmovle / cmovng有符号小于等于时传送ZF = 1 或 SF ≠ OF
cmovg / cmovnle有符号大于时传送ZF = 0 且 SF = OF
cmovge / cmovnl有符号大于等于时传送SF = OF
cmovb / cmovnae无符号小于时传送CF = 1
cmova / cmovnbe无符号大于时传送CF = 0 且 ZF = 0
cmovs负数时传送SF = 1
cmovns非负时传送SF = 0

典型示例

int abs_val(int x) { return (x >= 0) ? x : -x; }
abs_val:
    movl  %edi, %eax           # eax = x(先准备"then"分支的值)
    negl  %edi                 # edi = -x(先准备"else"分支的值)
    testl %eax, %eax           # 测试 x 的符号
    cmovs %edi, %eax           # 若 SF=1(x<0),eax = -x;否则 eax 保持 x
    ret

注意编译器两个分支都先算好,再用 cmov 选一个——这是与 jXX 的本质区别。

再看一个 max:

int max(int a, int b) { return (a > b) ? a : b; }
max:
    cmpl   %esi, %edi          # a - b
    movl   %esi, %eax          # eax = b(先假设结果是 b)
    cmovg  %edi, %eax          # 若 a > b,eax = a
    ret

优势与代价

优势:消除分支预测失败的惩罚

现代 CPU 是乱序超标量流水线,每条指令可能提前 10~20 个周期就进入流水线。遇到条件跳转,CPU 必须猜测走哪条路(分支预测)。猜错了,已经执行到一半的指令全部作废,重新填充流水线,这叫分支预测失败惩罚,通常损失 15~20 个时钟周期。

cmov 不跳转,CPU 不需要预测,流水线始终满载运行。

jXX 路径(预测失败时):
  取指 → 译码 → 执行 → [猜错!冲刷流水线] → 重新取指 → ... ≈ +15 周期惩罚

cmov 路径:
  两个分支同时计算 → cmov 选择结果 → 无任何惩罚

代价:必须无条件计算两个分支

cmov 要求两个分支的值都提前算好,这带来两个限制:

  1. 有副作用的分支不能用:如果分支里有写内存、调函数等操作,不能事先"都执行一遍"

    例 1:空指针解引用

    int val = (p != NULL) ? *p : 0;

    程序员的意图是:p 为 NULL 时直接返回 0,绝不去碰 *p。但若编译器用 cmov:

    movl   $0, %eax          # 先算 else:0
    movl   (%rdi), %ecx      # 先算 then:*p  ← p==NULL 时这里直接段错误!
    testq  %rdi, %rdi
    cmovne %ecx, %eax        # 再选:p!=NULL 就用 *p,否则用 0

    movl (%rdi), %ecx 是普通内存读,不管条件真假都会执行。p 是 NULL 时解引用地址 0,操作系统直接杀掉进程(Segmentation Fault)。所以编译器遇到这类写法会放弃 cmov,改回 jXX

    例 2:除零

    int result = (b != 0) ? (a / b) : 0;

    用 cmov 的话,a / b 这条除法指令无论条件真假都会执行。b == 0 时触发 CPU 除零异常,程序崩溃。同样,编译器不会对这种表达式生成 cmov。

  2. 计算代价不对称时可能更慢:两个分支都要算,如果其中一个很耗时(比如除法、内存访问),即使最终不用它的结果,时间也已花掉。此时不如用跳转直接跳过那个分支。

编译器的选择原则:分支预测容易命中(如循环计数器)→ 用 jXX;分支方向难以预测(如比较随机数据)→ 用 cmov。开 -O2 后编译器会自动判断,不需要手动干预。

2.10 操作数组合总览

x86 的 mov 指令支持哪些 source → dest 组合?唯一的禁区是 Mem → Mem(不能一条指令直接在两个内存地址之间搬数据)。

flowchart LR
    subgraph Source
        I["Imm(立即数)"]
        R["Reg(寄存器)"]
        M["Mem(内存)"]
    end

    subgraph "Src, Dest"
        IR["movl $0x4, %eax"]
        IM["movl $0x4, (%rdi)"]
        RR["movl %eax, %edx"]
        RM["movl %eax, (%rdi)"]
        MR["movl (%rdi), %eax"]
    end

    subgraph "C Analog"
        IR_C["a = 4"]
        IM_C["*p = 4"]
        RR_C["d = a"]
        RM_C["*p = a"]
        MR_C["a = *p"]
    end

    I --> IR --> IR_C
    I --> IM --> IM_C
    R --> RR --> RR_C
    R --> RM --> RM_C
    M --> MR --> MR_C
    M -.->|"❌ 不允许"| X["Mem → Mem"]

    style X fill:#7f1d1d,color:#fca5a5,stroke:#ef4444

如果确实需要 Mem → Mem(比如 memcpy),必须拆成两步:先 Mem → Reg,再 Reg → Mem


附:肉眼反编译速查

按类别列出"关键指纹",看到对应特征直接还原 C 结构。

控制流

关键特征C 构造
开头有 jmp .test,条件跳转在末尾while / for
无前置跳转,直接进循环体,条件跳转在末尾do-while
cmp + jXX 跳过一段代码if-else
ja .default + jmp *.table(,%rdi,8)(64位)/ jmp *.table(,%eax,4)(32位)switch(跳转表)
单条 jmp .label,无条件goto / 循环回跳
cmovXX 且全程无跳转指令三目运算符 ? :

函数调用

关键特征C 构造
pushq %rbp / movq %rsp, %rbp 开头函数定义(栈帧建立)
call 前依次填 edi/esi/edx/ecx(64位)/ call 前逆序 pushl 参数(32位)函数传参
函数末尾结果在 %eax / %rax返回值

数据访问

关键特征C 构造
movl N(%rdi), %eax,N 是常量结构体字段 p->field,N 是字段偏移
movslq + (%base,%idx,scale) 寻址数组下标 arr[i],scale 是元素大小
leaq 但没有实际读写内存地址计算或快速乘法(如 i * 4
setXX %al + movzbl %al, %eax布尔赋值 int b = (a > x)

while vs do-while

flowchart LR
    subgraph W["while 循环"]
        direction TB
        W1(["入口"]) --> W2["jmp .test"]
        W2 --> W3["条件检查"]
        W3 -->|"条件为真"| W4["循环体"]
        W4 --> W3
        W3 -->|"条件为假"| W5(["退出"])
    end

    subgraph D["do-while 循环"]
        direction TB
        D1(["入口"]) --> D2["循环体"]
        D2 --> D3["条件检查"]
        D3 -->|"条件为真"| D2
        D3 -->|"条件为假"| D4(["退出"])
    end

if-else vs switch

flowchart LR
    subgraph I["if-else"]
        direction TB
        I1["cmp / test"] --> I2{"jXX .else"}
        I2 -->|"不跳(then)"| I3["then 分支"]
        I2 -->|"跳转(else)"| I4["else 分支"]
        I3 --> I5["jmp .end"]
        I4 --> I5
    end

    subgraph S["switch 跳转表"]
        direction TB
        S1["cmpl $N, %edi"] --> S2{"ja .default\n范围检查"}
        S2 -->|"越界/负数"| S5["default"]
        S2 -->|"在范围内"| S3["jmp *.table(,%rdi,8)\n间接跳转"]
        S3 --> S4["case 1 / 2 / 3 ..."]
    end

看到 jmp * 就是 switch;看到 jmp .L_test 开头就是 while/for;看到没有任何跳转只有 cmov 就是三目运算符。


三、内存中的操作细节

3.1 栈帧的建立与销毁

栈 vs 栈帧:两个不同粒度的概念

栈(Stack) 是整块内存区域——程序启动时 OS 分配好的,专门用来支撑所有函数调用,%rsp 始终指向它的当前顶端。

栈帧(Stack Frame) 是栈里属于某一次函数调用的那一段切片。每次调用函数,就在栈上为这次调用划出一块专属空间;函数返回,这块空间即释放。

栈  =  整栋公寓楼(整块内存)
栈帧 =  每个住户的一套房间(某次函数调用的专属空间)

两个关键指针各司其职:

指针名称作用
%rsp栈顶指针追踪整个栈的当前顶端,随 push/pop 不断移动
%rbp帧基址指针固定在当前栈帧的起点,作为定位局部变量的锚点

%rbp 存在的意义:函数执行期间 %rsp 会随 push/pop 上下浮动,但 %rbp 钉死不动,所以 rbp-4rbp-8 可以稳定地寻址局部变量。

调用栈的动态变化

main() 调用 add() 为例,三个时刻的栈状态:

block-beta
    columns 3

    block:B1["① 调用前"]:1
        columns 1
        b1a["main 的局部变量"]
        b1b["← %rbp(main 帧锚点)"]
        b1c["← %rsp"]
    end

    block:B2["② call add 之后"]:1
        columns 1
        b2a["main 的局部变量"]
        b2b["← main 的 %rbp"]
        b2c["返回地址(自动压入)"]
        b2d["旧 %rbp(push 保存)"]
        b2e["← %rbp(add 帧锚点)"]
        b2f["add 的局部变量"]
        b2g["← %rsp"]
    end

    block:B3["③ add 返回后"]:1
        columns 1
        b3a["main 的局部变量"]
        b3b["← %rbp(恢复回 main)"]
        b3c["← %rsp(帧消失)"]
    end

    style B1 fill:#1e293b,color:#94a3b8
    style B2 fill:#1e293b,color:#94a3b8
    style B3 fill:#1e293b,color:#94a3b8
    style b2e fill:#1e3a5f,color:#7dd3fc
    style b2f fill:#1e3a5f,color:#7dd3fc
    style b2g fill:#1e3a5f,color:#7dd3fc

单个栈帧的内部结构

每次函数调用都会在栈上创建一个 栈帧(Stack Frame)

block-beta
    columns 1
    block:Caller["调用方的栈帧(高地址)"]
        A["..."]
    end
    B["返回地址(call 自动压入)"]
    C["旧 %rbp(push %rbp 保存调用方帧基址)"]
    block:Frame["当前函数栈帧 ← %rbp 指向这里(帧锚点)"]
        D["局部变量 a(rbp - 4)"]
        E["局部变量 b(rbp - 8)"]
        F["...(向低地址生长)"]
    end
    G["← %rsp(栈顶,低地址)"]

    style Caller fill:#334155,color:#94a3b8
    style Frame fill:#1e3a5f,color:#7dd3fc

对应的标准函数序言 / 尾声:

# === 序言 ===
pushq  %rbp               # 保存调用方的栈帧基址
movq   %rsp, %rbp         # 当前栈顶成为新栈帧的锚点
subq   $16, %rsp          # 为局部变量腾空间

# === 函数体 ===
movl   $42, -4(%rbp)      # 局部变量 a = 42
...

# === 尾声 ===
movq   %rbp, %rsp         # 释放局部变量
popq   %rbp               # 恢复调用方栈帧
ret                        # 弹出返回地址跳回去

leave 指令 = movq %rbp, %rsp + popq %rbp,编译器有时会用它简写。

识别栈帧有什么用?

直觉上可能觉得:反编译不就是找 call func 吗,认识栈帧有什么意义?但 call 只告诉你调用关系,栈帧告诉你函数的边界和内部结构——有几类情况 call 直接解决不了:

场景一:间接调用,call 里没有名字

call  *%rax              # 调用谁?不知道,要往前追 rax 从哪加载的
call  *(%rdi, %rcx, 8)  # 函数指针表,更复杂

这时需要识别栈帧结构,往前回溯数据流,才能推断出实际调用目标。

场景二:尾调用优化,call 被优化成 jmp

# C 代码:return foo(x);
# 编译器优化后:
jmp  foo        # 复用当前栈帧,省去建新帧的开销——不是 call,但这是函数调用

只盯着 call 会漏掉这类调用。看到 jmp 跳向别的函数、且当前函数 epilogue 消失,就是尾调用的特征。

场景三:内联函数,根本没有 call

小函数被编译器直接展开进调用方,所有指令都混在一起,没有任何 call。识别局部变量的偏移规律(rbp-N 的密集访问突然换了一批)可以帮你意识到"这一段逻辑原本是独立的函数"。

场景四:切出函数边界

call 告诉你"从哪里进入函数",但不告诉你函数从哪开始、在哪结束。在没有符号表的裸二进制里,靠 prologue(push %rbp; mov %rsp, %rbp)和 epilogue(pop %rbp; ret)才能准确切出函数范围。

场景五:还原局部变量

知道 %rbp 锚点在哪,才能把 rbp-4rbp-8rbp-24 分别对应成具体的变量,理解函数在操作什么数据——这是读懂函数逻辑的基础。

call  →  调用关系(谁调用了谁)
栈帧  →  函数边界 + 局部变量布局 + 优化后的隐式调用

两者配合,才能完整还原一段汇编的逻辑。

栈帧快照解读:old ebp 和 rtn addr 是什么

当你在汇编里看到 8(%ebp)4(%ebp) 时,你需要知道栈上那几个"特殊格"的含义。下面这张图是一次函数调用进入被调函数、执行序言之后的栈快照(32 位):

高地址
┌─────────────────────────┐
│   arg2(第 2 个参数)    │  ← ebp + 12
├─────────────────────────┤
│   arg1(第 1 个参数)    │  ← ebp + 8
├─────────────────────────┤
│   rtn addr(返回地址)   │  ← ebp + 4   ★
├─────────────────────────┤
│   old ebp(旧帧指针)    │  ← ebp + 0   ★    ← ebp 现在就指向这里
├─────────────────────────┤
│   局部变量 a             │  ← ebp - 4
├─────────────────────────┤
│   局部变量 b             │  ← ebp - 8
└─────────────────────────┘
低地址(← esp 在这附近)

old ebp 是什么?从哪来的?

序言第一条指令 pushl %ebp 把调用方(caller)当时的 %ebp 值压入栈——这就是 old ebp。它的目的是让被调函数(callee)结束时能把 %ebp 恢复回去。

# 进入函数时的序言:
pushl  %ebp          # ① old ebp 压栈(存调用方的帧基址)
movl   %esp, %ebp   # ② esp 成为新的 ebp(当前帧锚点建立)
subl   $8, %esp     # ③ 为局部变量腾空间

执行完 ② 之后,%ebp 就指向自己刚刚压进去的那个格子,即 old ebp 所在的位置——这就是为什么 ebp+0 就是 old ebpebp+4 就是返回地址(它在 old ebp 的上面,更早被压进栈的)。

rtn addr(返回地址)是什么?从哪来的?

call func 这条指令由 CPU 自动拆解为两步:

  1. call 之后那条指令的地址(即"如果函数返回了,下一条要执行的指令")压入栈
  2. 跳转到 func

这个被自动压进栈的地址就是 rtn addr(返回地址)。ret 指令执行时,CPU 从栈顶弹出它,直接跳回去继续执行调用方的代码。

为什么参数从 ebp+8 开始,而不是 ebp+4

call func 执行后、序言执行前,栈的状态是:

                     ← esp 就在这里
┌──────────────────┐
│   rtn addr       │ ← esp + 0(call 刚压进来的)
├──────────────────┤
│   arg1           │ ← esp + 4(caller 早一步压进来的)
├──────────────────┤
│   arg2           │ ← esp + 8
└──────────────────┘

序言执行 pushl %ebp 之后,old ebp 插在了 esp 的位置,把所有内容往上推了一格:

┌──────────────────┐
│   old ebp        │ ← esp(新压入)= ebp(movq 之后)  → ebp + 0
├──────────────────┤
│   rtn addr       │                                       → ebp + 4
├──────────────────┤
│   arg1           │                                       → ebp + 8
├──────────────────┤
│   arg2           │                                       → ebp + 12
└──────────────────┘

中间隔了一个 old ebp(4 字节)和一个 rtn addr(4 字节),所以第一个参数永远从 ebp+8 开始,第二个参数 ebp+12,依此类推,每个参数间隔 4 字节。

函数返回时(epilogue)发生了什么

movl   %ebp, %esp   # ① esp 回退到 ebp 位置(丢弃局部变量)
popl   %ebp         # ② 弹出 old ebp,%ebp 恢复成调用方的帧基址
ret                  # ③ 弹出 rtn addr,PC 跳回调用方

leave 指令 = ① + ②,是这两步的简写。

一句话速查

偏移是什么来源
ebp + 0old ebppushl %ebp(序言压入)
ebp + 4rtn addrcall func(CPU 自动压入)
ebp + 8第 1 个参数caller 调用前压栈
ebp + 12第 2 个参数caller 调用前压栈
ebp - 4第 1 个局部变量subl $N, %esp 分配
ebp - 8第 2 个局部变量同上

记忆口诀old ebprtn addr 是两个"门卫",占据 ebp+0ebp+4,把参数推到 ebp+8 以上。


实战:从汇编还原内联函数调用

以下是一段没有符号表、没有源码的 32 位汇编,目标是还原出原始 C 代码。

sum_of_squares:
    pushl  %ebp
    movl   %esp, %ebp
    subl   $4, %esp
    movl   8(%ebp), %eax
    imull  8(%ebp), %eax
    movl   %eax, -4(%ebp)
    movl   12(%ebp), %eax
    imull  12(%ebp), %eax
    addl   -4(%ebp), %eax
    leave
    ret

第一步:识别函数边界

pushl %ebp / movl %esp, %ebp 是 prologue,leave / ret 是 epilogue——这是一个完整函数,名字是 sum_of_squares

第二步:建立 ebp 偏移表

32 位参数调用前已压栈,在 正偏移;局部变量由 subl 分配,在 负偏移

ebp 偏移来源含义
ebp+8call 前压栈(第 1 参数)变量 a
ebp+12call 前压栈(第 2 参数)变量 b
ebp-4subl $4, %esp 分配临时变量 tmp

32 位读参数用正偏移ebp+N),局部变量用负偏移ebp-N)——正负分界是快速区分两者的方法。

第三步:逐段翻译

movl   8(%ebp), %eax       # eax = a
imull  8(%ebp), %eax       # eax = a * a        ← 第一段
movl   %eax, -4(%ebp)      # tmp = a * a

movl   12(%ebp), %eax      # eax = b
imull  12(%ebp), %eax      # eax = b * b        ← 第二段(结构完全相同)

addl   -4(%ebp), %eax      # eax = b*b + tmp = a*a + b*b

第四步:发现重复模式 → 推断内联

两段结构完全相同:读参数 → imull 自乘 → 结果留在 eax。同一个模式出现两次,高度怀疑原来有一个 square(x) 函数被内联展开:

flowchart LR
    A["square(a)\n读 ebp+8 → a×a → tmp"] --> C["tmp + square(b)"]
    B["square(b)\n读 ebp+12 → b×b → eax"] --> C
    C --> D["return eax"]

    style A fill:#1e3a5f,color:#7dd3fc
    style B fill:#1e3a5f,color:#7dd3fc
    style C fill:#334155,color:#94a3b8

第五步:还原 C 代码

// 被内联的小函数(编译器展开后 call 消失)
static inline int square(int x) {
    return x * x;
}

// 实际编译的函数
int sum_of_squares(int a, int b) {
    return square(a) + square(b);
}

分析套路总结

碰到看不懂的汇编,按这个顺序走:

  1. 定边界:找 prologue / epilogue,确认函数范围
  2. 建偏移表:正偏移 → 参数;负偏移 → 局部变量;全部映射成有意义的变量名
  3. 逐段翻译:每条指令写成伪代码注释
  4. 找重复:相同结构出现多次 → 原来可能是同一个被内联的函数
  5. 还原:把重复模式提取成函数,反推原始 C 代码

64 位的区别:参数通过寄存器(%edi, %esi...)传入,编译器再存到 ebp-N,所以参数和局部变量都在负偏移,正负分界法失效。本课程考试用 32 位,记住正偏移=参数即可。


32位 vs 64位偏移逻辑对比

经典帧指针模型下,规则两者相同

只要编译器保留了帧指针(ebp/rbp),正负偏移的含义在32位和64位里是一致的:

偏移方向访问的是原因
正偏移 ebp/rbp + N函数参数调用方压参数 → 压返回地址 → 压旧 ebp,参数留在更高地址
负偏移 ebp/rbp - N局部变量帧指针建立后向下分配,局部变量在更低地址

为什么32位满眼正偏移、64位满眼负偏移?

规则没变,是参数传递方式变了:

64位负偏移多的三个原因

  1. 寄存器传参:参数不在栈上,正偏移本来就少

  2. 红区(Red Zone):System V AMD64 ABI 规定 rsp 以下 128 字节为保留区域。叶子函数(不调用其他函数)可以直接用 [rsp-8][rsp-16] 存局部变量,不需要先执行 sub rsp, N 腾空间。注意:红区是以 rsp 为基准,不是 rbp

    # 叶子函数里可能看到(没有 sub rsp,直接用红区):
    movl  %edi, -4(%rsp)    # 直接往 rsp 下方写,不调整栈顶
    movl  %esi, -8(%rsp)
  3. 省略帧指针优化-fomit-frame-pointer):64位编译器开 -O2 后默认不建立 rbp 帧,rbp 当普通寄存器用,全程用 rsp 寻址。这时连 rbp 都没有,正偏移更不可能出现。

考试只用32位,记这个就够

正偏移 [ebp+8], [ebp+12]...  →  参数(从 +8 开始,每个参数 +4)
负偏移 [ebp-4], [ebp-8]...   →  局部变量

红区和省略帧指针是实际逆向的知识,考试不涉及。


3.2 call 与 ret 的内存操作

等价展开(32位)

call func
# CPU 内部做了两件事:
pushl  <call下一条指令的地址>   # ① 返回地址压栈:esp -= 4,写入栈顶
jmp    func                     # ② PC 跳到 func

ret
# CPU 内部做了一件事:
popl   %eip                     # 弹出返回地址:eip = *esp,esp += 4

pushl %eip / popl %eip 不能直接写在汇编里——这是 CPU 内部行为,只是用来理解原理。

栈上发生了什么

block-beta
    columns 3

    block:S1["call 之前"]:1
        columns 1
        s1a["main 的局部变量"]
        s1b["← %ebp"]
        s1c["← %esp"]
    end

    block:S2["call func 执行后"]:1
        columns 1
        s2a["main 的局部变量"]
        s2b["← %ebp(未变)"]
        s2c["返回地址(call 后那条指令)"]
        s2d["← %esp(自动 -4)"]
    end

    block:S3["ret 执行后"]:1
        columns 1
        s3a["main 的局部变量"]
        s3b["← %ebp"]
        s3c["← %esp(自动 +4,回到原位)"]
    end

    style S1 fill:#1e293b,color:#94a3b8
    style S2 fill:#1e293b,color:#94a3b8
    style S3 fill:#1e293b,color:#94a3b8
    style s2c fill:#1e3a5f,color:#7dd3fc
    style s2d fill:#1e3a5f,color:#7dd3fc

考试记忆锚点

容易混的点就两个,记住这张表:

callret
做几件事两件:压返回地址 + 跳转一件:弹返回地址(自动跳过去)
esp 变化-4(压栈)+4(弹栈)
eip 变化跳到 func 首地址跳到返回地址(call 后那条指令)
谁操作CPU 自动,不用手写CPU 自动,不用手写

一句话记住:call 是"留条子再出发",ret 是"拿条子回家"。条子 = 返回地址,压在栈顶。

函数调用的实质 = 栈上存一个返回地址。返回地址被覆盖 = 程序跳去错误的地方(栈溢出攻击的原理)。

3.3 内存访问模式总结

场景汇编写法在干什么
读局部变量movl -4(%rbp), %eax从栈帧取变量值
写局部变量movl %eax, -8(%rbp)把结果存回栈帧
读全局变量movl var(%rip), %eaxRIP 相对寻址取全局数据
数组取值movl (%rdi,%rsi,4), %eax首地址 + 下标 × 元素大小
结构体字段movl 4(%rdi), %eax基地址 + 固定偏移
指针解引用movl (%rdi), %eax去 rdi 存的地址取值
压栈pushq %raxrsp -= 8,数据写入栈顶
弹栈popq %rax读栈顶数据,rsp += 8

3.4 寄存器的身份分工(System V AMD64 ABI)

寄存器身份谁负责保存
%rax返回值调用方读取
%rdi, %rsi, %rdx, %rcx, %r8, %r9第 1~6 参数caller-saved(用完即弃)
%r10, %r11临时caller-saved
%rbx, %r12~%r15通用callee-saved(被调方必须保存恢复)
%rbp栈帧基址callee-saved
%rsp栈顶指针必须保持平衡

四、链接:把零件拼成可执行文件

链接(Linking)交互式图解:从源代码到可执行文件的全过程

4.1 为什么需要链接

编译器一次只处理 一个 .c 文件,生成一个 .o 目标文件。但一个程序通常有很多 .c 和库函数。链接器(ld 的工作就是把所有 .o 和库文件合并,解析函数名/变量名之间的引用关系,生成最终可执行文件。

flowchart LR
    A["main.c"] --> |"gcc -c"| B["main.o"]
    C["utils.c"] --> |"gcc -c"| D["utils.o"]
    E["libc.a / libc.so"] --> F
    B --> F["链接器 ld"]
    D --> F
    F --> G["a.out(可执行文件)"]

链接时做的两件核心事:

  1. 符号解析main.ocall add 写的是占位地址 → 链接器找到 addutils.o 里的真实地址,填进去
  2. 重定位:所有 .o 的地址都从 0 开始 → 链接器给每个段分配最终的虚拟地址,修正所有引用

4.2 静态链接 vs 动态链接

静态链接动态链接
时机编译时把库代码 复制进 可执行文件运行时才从共享库 加载
库文件.a(归档文件,多个 .o 打包).so(Linux)/ .dll(Windows)
可执行文件大小大(库代码全部嵌入)小(只存引用信息)
更新库必须重新编译整个程序替换 .so 文件即可,程序不用动
运行依赖无(自包含,拷到哪里都能跑)需要目标机器上有对应的 .so
内存效率差(每个程序各自一份库代码)好(多个程序共享同一份 .so 在内存中的映射)
flowchart TD
    subgraph 静态链接
        direction TB
        SA["main.o"] --> SB["链接器"]
        SC["libmath.a"] --> SB
        SB --> SD["a.out(内含 libmath 代码)"]
    end

    subgraph 动态链接
        direction TB
        DA["main.o"] --> DB["链接器"]
        DB --> DC["a.out(只含引用信息)"]
        DC --> |"运行时加载"| DD["libmath.so(共享库)"]
    end

动态链接的实际过程

sequenceDiagram
    participant OS as 操作系统
    participant LD as 动态链接器 ld-linux.so
    participant APP as 程序 a.out
    participant LIB as 共享库 libc.so

    OS->>APP: 加载 a.out 到内存
    OS->>LD: 启动动态链接器
    LD->>LD: 读取 a.out 的 .dynamic 段,找到依赖的 .so
    LD->>LIB: 加载 libc.so 到内存(或复用已加载的)
    LD->>APP: 修正 GOT/PLT 表中的函数地址
    LD->>APP: 跳转到 main() 开始执行

GOT(Global Offset Table)PLT(Procedure Linkage Table) 是动态链接的核心数据结构——GOT 存"这个函数到底在内存哪",PLT 是一段跳板代码负责第一次调用时触发链接器去填 GOT。

4.3 地址是什么时候确定的?

阶段地址状态例子
编译(.o从 0 开始的 占位地址call 0x0(add 的地址还不知道)
静态链接分配 虚拟地址 但写死在文件里call 0x401234(链接时确定)
动态链接代码中存的是 PLT 跳板call add@PLT(运行时才填真实地址)
加载运行OS 映射到 真实虚拟地址(可能被 ASLR 随机化)实际地址每次运行可能不同

ASLR(地址空间布局随机化) 是安全机制:每次运行程序,栈、堆、共享库的起始地址都随机。攻击者不能硬编码攻击地址。代码通过 RIP 相对寻址 + PIC(位置无关代码) 来兼容随机化。

4.4 符号解析:call 指令的"占位地址"

两个文件分别编译,main.c 调用 add.c 里的函数:

// add.c
int add(int a, int b) { return a + b; }

// main.c
int add(int a, int b);
int main() { return add(3, 4); }

未链接时,反汇编 main.oobjdump -d main.o):

0000000000000000 <main>:
   0:   55                    push   %rbp
   1:   48 89 e5              mov    %rsp,%rbp
   4:   be 04 00 00 00        mov    $0x4,%esi    # 参数 b=4
   9:   bf 03 00 00 00        mov    $0x3,%edi    # 参数 a=3
   e:   e8 00 00 00 00        call   13 <main+0x13>   ← 全是 0,占位
  13:   5d                    pop    %rbp
  14:   c3                    ret

链接后,反汇编 a.out

0000000000401136 <main>:
  401136:   55                    push   %rbp
  ...
  401144:   e8 d7 ff ff ff        call   401120 <add>   ← 已填入真实偏移
  401149:   5d                    pop    %rbp
  40114a:   c3                    ret

call 使用 PC 相对偏移(rel32),不是绝对地址:

rel32 = 目标地址 − call 指令结束地址
      = 0x401120  −  0x401149
      = −0x29  =  0xffffffd7  ✓

CPU 执行 call 时,%rip 已经指向下一条指令(0x401149),加上 rel32 就跳到 add。好处是:代码段整体移动时,只要两函数相对距离不变,这条指令就不用改。

4.5 重定位条目:链接器的 TODO 清单

汇编器看到 call addadd 的地址未知,就在 .rela.text 里留一条重定位条目

readelf -r main.o
Relocation section '.rela.text':
  Offset   Type              Sym. Name + Addend
  0x0f     R_X86_64_PLT32    add - 4
字段含义本例
Offset需要修改的字节位置(相对 .text 起始)0x0fe8 后面的 4 字节)
Type计算公式类型R_X86_64_PLT32:PC 相对
Sym. Name引用的符号add
Addend偏移修正量−4(补偿 rel32 字段本身的宽度)

链接器按公式计算填入值:

填入值 = 符号地址(S) + 加数(A) − 修改位置的运行时地址(P)
       = 0x401120    + (−4)    −  0x401145
       = 0xffffffd7   (即 −41)

把这 4 字节写进 e8 后面,占位符就被填满,目标文件变成可执行文件。

4.6 PLT / GOT:动态调用的汇编跳板

调用动态库函数(如 printf)时,汇编里是 call printf@plt。反汇编 PLT 段:

objdump -d a.out | grep -A6 '<printf@plt>'
0000000000401020 <printf@plt>:
  401020:   ff 25 ea 2f 00 00    jmp    *0x2fea(%rip)   # 跳到 GOT 中存的地址
  401026:   68 00 00 00 00       push   $0x0             # PLT 索引
  40102b:   e9 e0 ff ff ff       jmp    401010 <.plt>    # 跳解析器入口

三行指令形成一个跳板:第一行查 GOT,GOT 里若已填真实地址就直接跳过去;若尚未解析则跌落到 push + jmp,触发动态链接器解析。

懒绑定(Lazy Binding)流程:

sequenceDiagram
    participant C as 代码 call printf@plt
    participant PLT as PLT 跳板
    participant GOT as GOT[printf]
    participant LD as 动态链接器

    note over GOT: 初始值 = PLT+6(push 那行地址)

    C->>PLT: 第一次调用
    PLT->>GOT: jmp *GOT[printf]
    GOT-->>PLT: 尚未解析,跳回 PLT+6
    PLT->>LD: push 索引;jmp 解析器
    LD->>GOT: 找到 printf 真实地址,写入 GOT
    LD->>C: 跳转执行 printf

    note over C: 第二次及之后
    C->>PLT: call printf@plt
    PLT->>GOT: jmp *GOT[printf]
    GOT->>C: GOT 已填,直接跳 printf ✓

为什么这样设计:

GOT 覆写是经典漏洞:若程序存在任意写漏洞,攻击者可篡改 GOT 中 printf 的地址为 system,下次 printf(user_input) 就变成了 system(user_input)。现代系统用 RELRO(将 GOT 标记为只读)来防御。

4.7 位置无关代码(PIC)

共享库被多个进程以不同基地址加载,代码里不能有绝对地址——必须编译为 PIC(Position Independent Code)

extern int g;
int get_g() { return g; }

不加 -fPIC(位置相关):

get_g:
    mov    g(%rip), %eax     # RIP 相对,地址在链接时写死——不能做共享库
    ret

-fPIC(位置无关):

get_g:
    mov    g@GOTPCREL(%rip), %rax   # rax = GOT 里存 g 地址的那个槽
    mov    (%rax), %eax              # eax = g 的值(通过 GOT 间接)
    ret
位置相关(-fno-PIC位置无关(-fPIC
寻址方式绝对地址 / 固定偏移写死通过 GOT 间接寻址
能做共享库否(需重定位整个代码段)是(只需重定位 GOT)
性能略快(少一次内存间接)极小开销(通常可忽略)
适用场景普通主程序共享库 .so、开启 PIE 的主程序

为什么 .so 必须是 PIC.text 代码段在多进程间物理共享(同一块物理内存页被多个进程的页表同时映射)。若代码里含绝对地址,每个进程要单独一份——失去共享库的意义。GOT 每个进程有自己的副本(数据段),代码段完全不变,才能真正共享。

4.8 链接器的输入与输出文件

链接器本质上是一个"文件合并 + 地址修正器",理解它的 I/O 文件格式是掌握链接机制的前提。

输入文件

flowchart TD
    A["可重定位目标文件 .o\n(Relocatable Object File)\nELF 类型:ET_REL"]
    B["静态库 .a\n(Archive / Static Library)\n多个 .o 打包的归档"]
    C["共享目标文件 .so\n(Shared Object / Dynamic Library)\nELF 类型:ET_DYN"]
    D["链接器脚本 .ld\n(Linker Script)\n控制段的地址和布局"]
    E["链接器 ld"]

    A --> E
    B --> E
    C --> E
    D --> E
输入类型扩展名ELF 类型说明
可重定位目标文件.oET_REL汇编器的输出,含机器码和重定位条目,地址全为 0 占位
静态库.a归档(非 ELF)ar 命令把多个 .o 打包,链接器按需提取其中被引用的 .o
共享目标文件.soET_DYN动态库,链接时只记录引用,运行时才加载
链接器脚本.ld文本文件指定段的起始地址、内存布局、符号定义(嵌入式开发常用)

静态库(.a)的工作方式:

# 打包:把多个 .o 归档成 .a
ar rcs libmath.a add.o mul.o sub.o

# 链接时按需提取:只有 add.o 被引用,才会被提取链接进来
gcc main.o -L. -lmath -o a.out

链接器从左到右扫描命令行,遇到 .a 时只提取 解决了当前未定义引用.o——其余的 .o 被忽略。这就是为什么 -l 选项的顺序有时候会影响链接结果(被依赖的库要放后面)。

输出文件

输出类型ELF 类型如何生成特点
可执行文件ET_EXECgcc main.o -o a.out有入口地址,含程序头表,OS 可直接加载执行
共享目标文件ET_DYNgcc -shared -fPIC -o libfoo.so foo.o可被多进程共享映射,含动态符号表
可重定位目标文件ET_RELld -r a.o b.o -o ab.o(部分链接)仍含重定位信息,可继续参与后续链接
flowchart LR
    subgraph 输入
        O1["main.o (ET_REL)"]
        O2["utils.o (ET_REL)"]
        LA["libc.a (归档)"]
        SO["libpthread.so (ET_DYN)"]
    end

    LD["链接器 ld"]

    subgraph 输出
        EX["a.out (ET_EXEC)\n可直接执行"]
    end

    O1 --> LD
    O2 --> LD
    LA --> |"按需提取 .o"| LD
    SO --> |"记录依赖"| LD
    LD --> EX

4.9 各编程语言的链接栈

各编程语言链接栈对比:从包管理器到底层链接器的完整路径

点击语言查看完整链接栈
底层真正的链接器 —— 跨语言共享
GNU ld LLVM lld Microsoft link.exe Apple ld64 (Go 自带 tool link)
C、C++、Rust 编译出来的 .o 文件,最终都交给同一个 lld 合并 —— 连格式都一样(ELF / Mach-O / PE)

五、虚拟内存:程序看到的地址是"假"的

5.1 虚拟地址 vs 物理地址

程序中所有地址——%rip 指向的指令地址、%rsp 指向的栈地址、malloc 返回的堆地址——都是 虚拟地址,不是 RAM 上的真实物理地址。

flowchart LR
    subgraph 程序看到的
        VA["虚拟地址 0x7fff0010"]
    end
    subgraph CPU 硬件
        MMU["MMU(内存管理单元)"]
    end
    subgraph 实际 RAM
        PA["物理地址 0x2A0010"]
    end
    VA --> MMU --> PA

每个进程拥有自己 独立的虚拟地址空间。进程 A 的 0x7fff0010 和进程 B 的 0x7fff0010 映射到完全不同的物理内存——互相看不见,互相踩不到。

5.2 页表:虚拟地址到物理地址的映射

虚拟地址空间被切成固定大小的 页(Page)(通常 4KB = 4096 字节),物理内存也被切成相同大小的 页帧(Page Frame)。OS 维护一张 页表(Page Table) 记录映射关系:

flowchart LR
    subgraph 虚拟地址空间
        VP0["虚拟页 0"]
        VP1["虚拟页 1"]
        VP2["虚拟页 2"]
        VP3["虚拟页 3"]
        VP4["虚拟页 4"]
    end

    subgraph 页表
        PT["页表条目 PTE"]
    end

    subgraph 物理内存
        PF0["页帧 5"]
        PF1["页帧 12"]
        PF2["页帧 3"]
    end

    subgraph 磁盘 Swap
        SW["换出的页"]
    end

    VP0 --> PT --> PF0
    VP1 --> PT --> PF1
    VP3 --> PT --> PF2
    VP2 --> PT --> SW
    VP4 -.- |"未分配"| PT

虚拟页的三种状态

5.3 进程的虚拟地址空间布局

每个进程看到的虚拟地址空间长这样(以 Linux x86-64 为例):

block-beta
    columns 1
    A["内核空间(用户不可访问)"]
    space
    B["栈 Stack ↓ ← %rsp"]
    space
    C["↑ 共享库 .so(mmap 区域)"]
    space
    D["↑ 堆 Heap(malloc / new)"]
    E[".bss — 未初始化全局变量"]
    F[".data — 已初始化全局变量"]
    G[".rodata — 只读数据(字符串常量等)"]
    H[".text — 代码段(机器码)← %rip 从这取指"]

    style A fill:#4a1942,color:#e8a0d0
    style B fill:#1e3a5f,color:#7dd3fc
    style C fill:#3b4252,color:#d8dee9
    style D fill:#2e4057,color:#88c0d0
    style E fill:#334155,color:#94a3b8
    style F fill:#334155,color:#94a3b8
    style G fill:#334155,color:#94a3b8
    style H fill:#1a472a,color:#a3d9a5

栈从高地址 向下 生长,堆从低地址 向上 生长,中间是共享库映射区。OS 通过页表为每段设置不同权限:.text 只读+可执行,.data 可读写+不可执行,栈可读写+不可执行(NX bit 防御)。

5.4 Swap:当物理内存不够用

物理 RAM 是有限的。当进程用掉的页超过物理内存容量时,OS 会把 最近不用的页 写到磁盘的 交换空间(Swap),腾出物理页帧给急需的进程。等被换出的页再次被访问时,OS 再把它从磁盘读回来。

sequenceDiagram
    participant APP as 程序
    participant MMU as MMU
    participant OS as 操作系统
    participant RAM as 物理内存
    participant DISK as 磁盘 Swap

    APP->>MMU: 访问虚拟地址 0x7fff0010
    MMU->>MMU: 查页表:该页不在物理内存!
    MMU->>OS: 触发缺页异常 Page Fault
    OS->>RAM: 物理内存满了,选一个"冷页"换出
    OS->>DISK: 冷页写入 Swap
    OS->>DISK: 从 Swap 读回需要的页
    OS->>RAM: 放入空出的页帧
    OS->>MMU: 更新页表映射
    MMU->>APP: 重新执行刚才的访问指令,成功

Swap 的代价是巨大的——磁盘比内存慢 10 万倍以上。如果系统频繁 Swap(称为 thrashing / 抖动),性能会断崖式下降。这就是为什么内存不够时电脑会卡成幻灯片。

5.5 虚拟内存的好处总结

好处怎么实现的
进程隔离每个进程有独立页表,互相看不见内存
安全保护页表条目带权限位(读/写/执行),越权触发异常
内存超售虚拟空间可以比物理内存大(靠 Swap 兜底)
共享库多进程的页表可以映射到同一物理页帧(共享 .so
按需加载程序启动时不需要把所有代码/数据都加载到 RAM
ASLR每次映射地址随机化,增加攻击难度

六、目标代码与机器码:汇编变成二进制是什么

6.1 汇编 → 机器码的对应关系

汇编指令是助记符,汇编器(as)把它翻译成 CPU 直接执行的 二进制机器码

汇编指令                    机器码(十六进制)      二进制
─────────────────────────────────────────────────────────
movl  $1, %eax            B8 01 00 00 00        10111000 00000001 ...
addl  $5, %eax            83 C0 05              10000011 11000000 00000101
ret                       C3                    11000011
pushq %rbp                55                    01010101
movq  %rsp, %rbp          48 89 E5              01001000 10001001 11100101

几件重要的事

  1. x86 指令变长(1~15字节)。ret 只要 1 字节,movl $1, %eax 需要 5 字节
  2. 立即数按小端序存储$1 编码为 01 00 00 00
  3. 寄存器用编号编入指令B8 = mov 到 eax,B9 = mov 到 ecx(只差寄存器编号位)

6.2 用 objdump 看真实的机器码

gcc -c -O0 example.c -o example.o
objdump -d example.o
0000000000000000 <add>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d fc                mov    %edi,-0x4(%rbp)
   7:   89 75 f8                mov    %esi,-0x8(%rbp)
   a:   8b 45 fc                mov    -0x4(%rbp),%eax
   d:   03 45 f8                add    -0x8(%rbp),%eax
  10:   5d                      pop    %rbp
  11:   c3                      ret

偏移地址从 0 开始——这是 .o 文件(未链接),链接后会变成最终虚拟地址。

6.3 指令编码结构

x86 指令的编码格式(理解即可):

flowchart LR
    A["前缀(可选)"] --> B["REX(64位可选)"] --> C["操作码(1~3B)"] --> D["ModR/M(可选)"] --> E["SIB(可选)"] --> F["位移量(可选)"] --> G["立即数(可选)"]
    
    style C fill:#2e8b57,color:#fff
    style D fill:#4682b4,color:#fff

拆解 movl %edi, -0x4(%rbp)89 7d fc

字节含义
89操作码:mov r/m32, r32
7dModR/M:edi 为源,[rbp + disp8] 为目标
fc位移量 -40xfc = -4 的补码)

6.4 从源码到可执行文件的完整链路

flowchart LR
    A["hello.c"] -->|"gcc -E(预处理)"| B[".i 展开宏"]
    B -->|"gcc -S(编译)"| C[".s 汇编文本"]
    C -->|"as(汇编)"| D[".o 目标文件"]
    D -->|"ld(链接)"| E["a.out 可执行文件"]
gcc -E hello.c -o hello.i       # 预处理:展开 #include、#define
gcc -S hello.c -o hello.s       # 编译:C → 汇编
gcc -c hello.c -o hello.o       # 汇编:汇编 → 目标文件
gcc    hello.c -o hello          # 一步到位

objdump -d hello.o              # 反汇编 .text 段
objdump -s -j .rodata hello.o   # 看只读数据段
readelf -h hello.o              # 看 ELF 头信息

6.5 ELF 格式详解

ELF(Executable and Linkable Format)是 Linux/Unix 上目标文件、可执行文件和共享库的统一格式。三种 ELF 文件类型共用同一个头部结构,但内部组成有所不同。

ELF 文件的三种类型

类型e_type典型文件说明
可重定位ET_RELfoo.o汇编器输出,地址 0 起,含重定位条目
可执行ET_EXECa.out链接器输出,有固定入口,OS 直接加载
共享对象ET_DYNlibfoo.soPIC 代码,运行时被加载器映射

ELF 文件整体布局

block-beta
    columns 1
    A["ELF Header(64字节)\n魔数 / 文件类型 / 架构 / 入口地址 / 段表/节表偏移"]
    B["Program Header Table(可选)\n描述「段(Segment)」→ 给加载器/OS 用\n.o 文件没有;可执行文件和 .so 必须有"]
    C["节区(Sections)\n.text  .data  .bss  .rodata\n.symtab  .strtab  .rel.text  .got  .plt  ..."]
    D["Section Header Table(可选)\n描述每个节的名称、类型、偏移、大小\n链接器用;strip 后可删除"]

    style A fill:#4a1942,color:#e8a0d0
    style B fill:#1e3a5f,color:#7dd3fc
    style C fill:#1a472a,color:#a3d9a5
    style D fill:#2e4057,color:#88c0d0

核心区别节(Section) 是链接器视角的逻辑划分;段(Segment) 是加载器视角的内存映射单位。多个相邻节可以合并进同一个段。

ELF Header 关键字段

readelf -h a.out
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 ...   ← 魔数(0x7f + "ELF")
  Class:                             ELF64   ← 32位/64位
  Data:                              2's complement, little endian
  Type:                              EXEC (Executable file)   ← ET_EXEC
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x401050   ← main 的虚拟地址(_start 入口)
  Start of program headers:          64 (bytes into file)
  Start of section headers:          14312 (bytes into file)
  Number of program headers:         13
  Number of section headers:         31
字段含义
Magic7f 45 4c 46("ELF");第 5 字节 01=32位 02=64位
TypeET_REL=1 / ET_EXEC=2 / ET_DYN=3
Entry point程序执行起点(.o 中为 0,可执行文件中为 _start 地址)
phoff / shoff程序头表 / 节头表在文件中的字节偏移
phnum / shnum程序头 / 节头的条目数量

节(Section)详解

readelf -S a.out   # 查看节头表
节名类型权限内容备注
.textPROGBITSr-x机器码指令只读可执行,多进程共享
.rodataPROGBITSr--字符串常量、跳转表只读,不可执行
.dataPROGBITSrw-已初始化全局/静态变量运行时可写
.bssNOBITSrw-未初始化全局/静态变量占文件 0 字节,加载时由 OS 清零
.symtabSYMTAB符号表(函数名、变量名 → 地址)strip 后可删除
.dynsymDYNSYM动态符号表(运行时链接需要)strip不能删除
.strtabSTRTAB符号名字符串(.symtab 用)存符号的实际名字
.dynstrSTRTAB动态符号名字符串(.dynsym 用)运行时动态链接器读取
.rel.text / .rela.textRELA重定位条目(.o 中)指示链接器在哪里填地址
.gotPROGBITSrw-全局偏移表存外部变量/函数的运行时地址
.got.pltPROGBITSrw-PLT 用的 GOT 槽懒绑定时被动态链接器填写
.pltPROGBITSr-xPLT 跳板代码每个动态符号一条
.dynamicDYNAMICrw-动态链接元信息依赖的 .so 列表、初始化函数等
.shstrtabSTRTAB节名字符串表.text.data 等节的名字

.bss 为什么不占文件空间?

未初始化的全局变量(如 int arr[10000];)在程序加载前全是 0,没必要在文件里存 40000 个零字节——节头表只记录它的大小,OS 加载时 mmap 一块清零的匿名页就行了。这是节省磁盘空间的经典优化。

段(Segment)与节的关系

可执行文件中,链接器把多个节 合并 进少数几个段(Segment),OS 按段来 mmap

readelf -l a.out   # 查看程序头表(段)
LOAD           0x000000 0x400000 0x400000 0x0002e5 0x0002e5 R E    # 代码段
LOAD           0x000f10 0x600f10 0x600f10 0x000118 0x000120 RW     # 数据段
DYNAMIC        0x000f28 0x600f28 0x600f28 0x0000d0 0x0000d0 RW
flowchart LR
    subgraph "ELF 文件(节视图,链接器用)"
        T[".text"]
        R[".rodata"]
        D[".data"]
        B[".bss"]
        G[".got"]
        P[".plt"]
    end

    subgraph "进程内存(段视图,OS 加载)"
        CS["代码段 (R+X)\n.text + .rodata + .plt"]
        DS["数据段 (R+W)\n.data + .bss + .got"]
    end

    T --> CS
    R --> CS
    P --> CS
    D --> DS
    B --> DS
    G --> DS

节与段的对应关系由 Program Header Table 中每条 LOAD 条目的 filesz/memsz 和偏移量决定。

符号表(.symtab)结构

readelf -s a.out | head -20
Symbol table '.symtab' contains 67 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
    ...
    47: 0000000000401136    21 FUNC    GLOBAL DEFAULT   14 main
    48: 0000000000401120    22 FUNC    GLOBAL DEFAULT   14 add
    51: 0000000000404028     4 OBJECT  GLOBAL DEFAULT   24 counter
字段含义
Value符号的虚拟地址(.o 中为 0,链接后为真实地址)
Size函数/变量的字节大小
TypeFUNC=函数、OBJECT=变量/数组、NOTYPE=未知
BindGLOBAL=全局可见、LOCAL=文件内可见(static)、WEAK=弱符号
Ndx所属节的编号;UND=未定义(需从其他 .o 引入)

弱符号(WEAK):定义了但允许被其他 .o 中同名的强符号覆盖,常用于提供可替换的默认实现(__attribute__((weak)))。

常用 ELF 探查命令速查

# 查看 ELF 头(文件类型、入口、架构)
readelf -h file.o

# 查看所有节及其大小、类型
readelf -S file.o

# 查看符号表
readelf -s file.o

# 查看重定位条目
readelf -r file.o

# 查看程序头表(段,仅可执行文件和 .so)
readelf -l a.out

# 反汇编 .text 节
objdump -d file.o

# 查看特定节的原始内容(如 .rodata)
objdump -s -j .rodata file.o

# 查看动态链接依赖
ldd a.out

# 查看 .dynamic 节(动态链接元信息)
readelf -d a.out

.o 里的地址都是 0 开始的占位符,链接器负责合并所有 .o、解析符号引用、填入真实虚拟地址——这就是 重定位

6.6 完整实例:从 C 到二进制

int square(int x) { return x * x; }

编译为汇编(gcc -S -O1):

square:
    movl    %edi, %eax         # eax = x(第1参数)
    imull   %edi, %eax         # eax = x * x
    ret

汇编为机器码(objdump -d):

0000000000000000 <square>:
   0:   89 f8       mov    %edi,%eax
   2:   0f af c7    imul   %edi,%eax
   5:   c3          ret

总共 6 字节——89 f8 0f af c7 c3。CPU 看到的就是这 48 个比特,不知道什么叫 squareintx——变量名、类型信息全部在编译过程中被消化掉了,留下的只有纯粹的二进制指令。



0 / 2000
正在加载评论...